package com.nulldev.util.web.URLUtils.fparser;

import java.io.Serializable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

import com.nulldev.util.data.Arrays.maps.FastMaps;
import com.nulldev.util.web.URLUtils.fparser.exceptions.InvalidURLReferenceException;
import com.nulldev.util.web.URLUtils.fparser.exceptions.MalformedURLException;

public final class URL implements Serializable {

	/**
	 * Unique ID for serialization purposes.
	 */
	private static final long serialVersionUID = 80443L;

	/**
	 * URLParser to be used to parse the URL string into the URL object. Do not
	 * serialize.
	 */
	private transient final static URLParser URL_PARSER = new DefaultURLParser();

	private final String scheme;
	private final String username;
	private final String password;
	private final String host;
	private final String hostname;
	private final Integer port;
	private final String path;
	private final String rawPath;
	private final String query;
	private final String fragment;
	private final String opaque;

	/**
	 * Cached parsed query string key-value pairs. Do not serialize.
	 */
	private transient Map<String, Collection<String>> parsedQueryPairs;

	/**
	 * Cached string representation of the URL. Do not serialize.
	 */
	private transient String stringRepresentation;

	/**
	 * Protect instantiation of class. Use public parse method instead to construct
	 * URLs. Builder is for protected use only.
	 */
	URL(String scheme, String username, String password, String host, String path, String query, String fragment, String opaque) {
		this.scheme = mapToNullIfEmpty(scheme);
		this.username = mapToNullIfEmpty(username);
		this.password = mapToNullIfEmpty(password);
		this.host = mapToNullIfEmpty(host);
		this.hostname = extractHostname(host);
		this.port = extractPort(host);
		this.path = mapToNullIfEmpty(path);
		this.rawPath = null;
		this.query = mapToNullIfEmpty(query);
		this.fragment = mapToNullIfEmpty(fragment);
		this.opaque = mapToNullIfEmpty(opaque);
	}

	URL(String scheme, String username, String password, String host, String path, String rawPath, String query, String fragment, String opaque) {
		this.scheme = mapToNullIfEmpty(scheme);
		this.username = mapToNullIfEmpty(username);
		this.password = mapToNullIfEmpty(password);
		this.host = mapToNullIfEmpty(host);
		this.hostname = extractHostname(host);
		this.port = extractPort(host);
		this.path = mapToNullIfEmpty(path);
		this.rawPath = mapToNullIfEmpty(rawPath);
		this.query = mapToNullIfEmpty(query);
		this.fragment = mapToNullIfEmpty(fragment);
		this.opaque = mapToNullIfEmpty(opaque);
	}

	URL(String scheme, String username, String password, String hostname, Integer port, String path, String rawPath, String query, String fragment,
			String opaque) {
		this.scheme = mapToNullIfEmpty(scheme);
		this.username = mapToNullIfEmpty(username);
		this.password = mapToNullIfEmpty(password);
		this.host = mergeHostPortIfSet(hostname, port);
		this.hostname = mapToNullIfEmpty(hostname);
		this.port = port;
		this.path = mapToNullIfEmpty(path);
		this.rawPath = mapToNullIfEmpty(rawPath);
		this.query = mapToNullIfEmpty(query);
		this.fragment = mapToNullIfEmpty(fragment);
		this.opaque = mapToNullIfEmpty(opaque);
	}

	/**
	 * Returns a new URL object after parsing the provided URL string.
	 */
	public static URL parse(String url) throws MalformedURLException {
		return URL_PARSER.parse(url);
	}

	/**
	 * Returns the scheme ('http' or 'file' or 'ftp' etc...) of the URL if it
	 * exists.
	 */
	public String getScheme() {
		return scheme;
	}

	/**
	 * Returns the username part of the userinfo if it exists.
	 */
	public String getUsername() {
		return username;
	}

	/**
	 * Returns the password part of the userinfo if it exists.
	 */
	public String getPassword() {
		return password;
	}

	/**
	 * Returns the host ('www.example.com' or '192.168.0.1:8080' or
	 * '[fde2:d7de:302::]') of the URL if it exists.
	 */
	public String getHost() {
		return host;
	}

	/**
	 * Returns the hostname part of the host ('www.example.com' or '192.168.0.1' or
	 * '[fde2:d7de:302::]') if it exists.
	 */
	public String getHostname() {
		return hostname;
	}

	private static final Map<String, Integer> PORT_CACHE = FastMaps.uniMap(new Object[][]
		{
				{ "http", 80 },
				{ "https", 443 },
				{ "gopher", 70 }, });

	/**
	 * Returns the port part of the host (i.e. 80 or 443 or 3000) if it exists.
	 */
	public Integer getPort() {
		if (this.port == null) {
			final String scheme = getScheme();
			if (scheme == null)
				return 443;
			else if (PORT_CACHE.containsKey(scheme))
				return PORT_CACHE.get(scheme);
			else
				return this.port;
		} else
			return this.port;
	}

	/**
	 * Returns the unescaped path ('/path/to/the;/file.html') of the URL if it
	 * exists.
	 */
	public String getPath() {
		return path;
	}

	/**
	 * Returns the raw path ('/path/to/the%3B/file.html') of the URL if it exists.
	 */
	public String getRawPath() {
		return rawPath;
	}

	/**
	 * Returns the query ('?q=foo{@literal &}bar') of the URL if it exists.
	 */
	public String getQuery() {
		return query;
	}

	/**
	 * Returns the fragment ('#foo{@literal &}bar') of the URL if it exists.
	 */
	public String getFragment() {
		return fragment;
	}

	/**
	 * Returns the opaque part of the URL if it exists.
	 */
	public String getOpaque() {
		return opaque;
	}

	/**
	 * Returns a java.net.URL object from the parsed url.
	 *
	 * @throws java.net.MalformedURLException if something went wrong while created
	 *                                        the new object.
	 */
	public java.net.URL toJavaURL() throws java.net.MalformedURLException {
		return new java.net.URL(toString());
	}

	/**
	 * Returns a possibly sane java.net.URL object from the parsed url.
	 *
	 * @throws java.net.MalformedURLException if something went wrong while created
	 *                                        the new object.
	 */
	public java.net.URL toFastJavaURL() throws java.net.MalformedURLException {
		return new java.net.URL(getScheme() == null ? "https" : getScheme(), getHost(), getPort() == null ? 443 : getPort(),
				getRawPath() + (getQuery() != null ? "?" + getQuery() : ""));
	}

	/**
	 * Returns a java.net.URI object from the parsed url.
	 *
	 * @throws java.net.URISyntaxException if something went wrong while created the
	 *                                     new object.
	 */
	public java.net.URI toJavaURI() throws URISyntaxException {
		return new URI(toString());
	}

	/**
	 * Returns a map of key-value pairs from the parsed query string.
	 */
	public Map<String, Collection<String>> getQueryPairs() {
		if (parsedQueryPairs != null) {
			return parsedQueryPairs;
		}
		parsedQueryPairs = FastMaps.map();

		if (!nullOrEmpty(query) && !query.equals("?")) {
			String[] pairs = query.split("&");
			for (String pair : pairs) {
				String[] parts = pair.split("=");
				if (parts.length > 0 && !parts[0].isEmpty()) {
					Collection<String> existing = parsedQueryPairs.getOrDefault(parts[0], new ArrayList<String>());
					if (parts.length == 2) {
						existing.add(parts[1]);
					}
					parsedQueryPairs.put(parts[0], existing);
				}
			}
		}

		return parsedQueryPairs;
	}

	/**
	 * Returns true if the two Objects are instances of URL and their string
	 * representations match.
	 */
	@Override
	public boolean equals(Object other) {
		if (other == null) {
			return false;
		}
		if (!(other instanceof URL)) {
			return false;
		}
		return toString().equals(other.toString());
	}

	/**
	 * Returns a string representation of the all parts of the URL that are not
	 * null.
	 */
	@Override
	public String toString() {
		if (stringRepresentation != null) {
			return stringRepresentation;
		}

		boolean hasScheme = !nullOrEmpty(scheme);
		StringBuffer sb = new StringBuffer();
		if (hasScheme) {
			sb.append(scheme);
			sb.append(":");
		}
		if (!nullOrEmpty(opaque)) {
			sb.append(opaque);
		} else {
			if (hasScheme || !nullOrEmpty(host)) {
				if (hasScheme) {
					sb.append("//");
				}
				if (!nullOrEmpty(username)) {
					sb.append(PercentEncoder.encode(username, URLPart.CREDENTIALS));
					if (!nullOrEmpty(password)) {
						sb.append(":");
						sb.append(PercentEncoder.encode(password, URLPart.CREDENTIALS));
					}
					sb.append("@");
				}
				if (!nullOrEmpty(host)) {
					sb.append(PercentEncoder.encode(host, URLPart.HOST));
				}
			}
			if (!nullOrEmpty(rawPath)) {
				sb.append(rawPath);
			} else if (!nullOrEmpty(path)) {
				if (path.indexOf('/') != 0 && !"*".equals(path)) {
					sb.append("/");
				}
				sb.append(path);
			}
		}
		if (!nullOrEmpty(query)) {
			sb.append("?");
			// Only append '?' if that's all there is to the query string
			if (!"?".equals(query)) {
				sb.append(query);
			}
		}
		if (!nullOrEmpty(fragment)) {
			sb.append("#");
			sb.append(fragment);
		}

		stringRepresentation = sb.toString();

		return stringRepresentation;
	}

	/**
	 * Returns the hash code of the URL.
	 */
	@Override
	public int hashCode() {
		return toString().hashCode();
	}

	/**
	 * Returns true if the parameter string is neither null nor empty ("").
	 */
	private boolean nullOrEmpty(String str) {
		return str == null || str.isEmpty();
	}

	/**
	 * Returns true if URL is opaque.
	 */
	public boolean isOpaque() {
		return !nullOrEmpty(opaque);
	}

	/**
	 * Returns true if URL is absolute.
	 */
	public boolean isAbsolute() {
		return !nullOrEmpty(scheme);
	}

	public URL resolveReference(String ref) throws MalformedURLException, InvalidURLReferenceException {
		URL url = URL_PARSER.parse(ref);
		return resolveReference(url);
	}

	/**
	 * Returns the resolved reference URL using the instance URL as a base.
	 * <p>
	 * If the reference URL is absolute, then it simply creates a new URL that is
	 * identical to it and returns it. If the reference and the base URLs are
	 * identical, a new instance of the reference is returned.
	 *
	 * @throws InvalidURLReferenceException if the provided ref URL is invalid or if
	 *                                      the base URL is not absolute.
	 */
	public URL resolveReference(URL ref) throws InvalidURLReferenceException {
		if (!isAbsolute()) {
			throw new InvalidURLReferenceException("base url is not absolute");
		}
		if (ref == null) {
			throw new InvalidURLReferenceException("reference url is null");
		}

		URLBuilder builder = new URLBuilder().setScheme(ref.getScheme()).setUsername(ref.getUsername()).setPassword(ref.getPassword()).setHost(ref.getHost())
				.setPath(ref.getPath()).setQuery(ref.getQuery()).setFragment(ref.getFragment()).setOpaque(ref.getOpaque());

		if (!ref.isAbsolute()) {
			builder.setScheme(scheme);
		}

		if (!nullOrEmpty(ref.scheme) || !nullOrEmpty(ref.host)) {
			builder.setPath(PathResolver.resolve(ref.path, ""));
			return builder.build();
		}

		if (ref.isOpaque() || isOpaque()) {
			return builder.build();
		}

		return builder.setHost(host).setUsername(username).setPassword(password).setPath(PathResolver.resolve(path, ref.path)).build();
	}

	private static String mapToNullIfEmpty(String str) {
		return str != null && !str.isEmpty() ? str : null;
	}

	/**
	 * Returns the full host (hostname:port), maps to null if none are set.
	 */
	private static String mergeHostPortIfSet(String hostname, Integer port) {
		StringBuilder sb = new StringBuilder();
		boolean exists = false;
		if (hostname != null) {
			sb.append(hostname);
			exists = true;
		}
		if (port != null) {
			sb.append(":");
			sb.append(port);
			exists = true;
		}
		if (exists) {
			return sb.toString();
		}
		return null;
	}

	/**
	 * Returns the hostname part of the host ('www.example.com' or '192.168.0.1' or
	 * '[fde2:d7de:302::]') if it exists.
	 */
	private static String extractHostname(String host) {
		if (host != null) {
			int separator = host.lastIndexOf(":");
			if (separator > -1) {
				return host.substring(0, separator);
			}
			return host;
		}
		return null;
	}

	/**
	 * Returns the port part of the host (i.e. 8080 or 443 or 3000) if it exists.
	 */
	private static Integer extractPort(String host) {
		if (host != null) {
			int separator = host.lastIndexOf(":");
			if (separator > -1) {
				String part = host.substring(separator + 1, host.length());
				if (part != null && part != "") {
					try {
						return Integer.parseInt(part);
					} catch (NumberFormatException exception) {
						return null;
					}
				}
			}
		}
		return null;
	}
}